Skip to content

Fix ClaudeSDKClient.receive_response() returning before background agent work finishes - #1224

Open
dineshyadav03 wants to merge 2 commits into
anthropics:mainfrom
dineshyadav03:fix-1138-receive-response-deferred-result
Open

Fix ClaudeSDKClient.receive_response() returning before background agent work finishes#1224
dineshyadav03 wants to merge 2 commits into
anthropics:mainfrom
dineshyadav03:fix-1138-receive-response-deferred-result

Conversation

@dineshyadav03

Copy link
Copy Markdown

Fixes #1138

ClaudeSDKClient.receive_response() stopped on the first ResultMessage it saw, but a result frame only marks the end of one turn, not necessarily the run: when a delegated background agent/workflow task (local_agent/local_workflow) is still in flight, the CLI emits that result to close out the current turn, then continues with a follow-up turn once the task completes, ending in a second, later ResultMessage. receive_response() had no way to tell the two apart, so it returned on the first (intermediate) one and silently missed everything from the follow-up turn — including the real final result.

Query already tracks in-flight delegated agent tasks via _track_task_lifecycle()/_inflight_tasks (added for #1088, to avoid closing stdin too early). This reuses that same signal: each result frame sent while a delegated task is in flight has its uuid recorded, and Query.is_deferred_result() lets receive_response() recognize such a frame and keep reading instead of returning early.

Adds a regression test (test_receive_response_waits_for_deferred_result) reproducing the exact turn-boundary scenario: an intermediate result while a background agent task is in flight, followed by the task completing and a follow-up turn ending in the real final result.

Test plan

  • python -m ruff check src/ tests/ scripts/ — clean
  • python -m mypy src/ scripts/ — clean
  • python -m pytest tests/ — 1402 passed, 14 skipped

Generated by Claude Code

…ent work finishes

receive_response() stopped on the first ResultMessage it saw, but a result
frame only marks the end of one turn, not necessarily the run: when a
delegated background agent/workflow task is still in flight, the CLI emits
that result to close out the current turn and then continues with a
follow-up turn once the task completes, ending in a second, later
ResultMessage. receive_response() had no way to tell the two apart, so it
returned on the first (intermediate) one and silently missed everything
from the follow-up turn, including the real final result (anthropics#1138).

Query already tracks in-flight delegated agent tasks via
_track_task_lifecycle()/_inflight_tasks (added for anthropics#1088, to avoid closing
stdin too early), so this reuses that same signal: each "result" frame sent
while a delegated task is in flight has its uuid recorded, and
Query.is_deferred_result() lets receive_response() recognize such a frame
and keep reading instead of returning early.

Fixes anthropics#1138.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_deferred_result_ids is only cleared by receive_response() via is_deferred_result(). Callers using the public lower-level receive_messages() API never consume those markers, so every intermediate background-task result UUID remains in the set for the lifetime of the Query. Could the deferred marker be retired when the message is delivered, or otherwise bounded independently of which receive API the caller uses?

is_deferred_result() is the only thing that retires an entry from
_deferred_result_ids, and only ClaudeSDKClient.receive_response() calls
it. A caller reading raw frames via receive_messages() instead never
consults it, so a long-lived Query doing many delegated background-agent
turns would accumulate one entry per deferred result for its whole
lifetime.

Cap the set at 128 entries with FIFO eviction, so it self-bounds
regardless of which receive API the caller uses. The real fix for a
given caller is still calling is_deferred_result() (i.e. using
receive_response()); this is a backstop, not a behavior change for
existing receive_response() callers.
@dineshyadav03

Copy link
Copy Markdown
Author

@sylvesterkaczmarek Good catch — you're right, receive_messages() never calls is_deferred_result(), so nothing retires those markers for callers using that lower-level API. Pushed a fix: _deferred_result_ids is now capped at 128 entries with FIFO eviction, so it self-bounds regardless of which receive API the caller uses, rather than growing unbounded for the life of the Query. Added a regression test (test_deferred_result_ids_bounded_via_receive_messages) that drives 178 deferred results purely through receive_messages() and asserts the internal set never exceeds the cap — confirmed it fails without the fix (178 entries) and passes with it.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deferred-result bookkeeping is now independently bounded for callers that consume only receive_messages(). The cap is above the 100-message stream buffer, so a marker that is still queued for receive_response() cannot be evicted by unread frames before the consumer sees it. The raw-message regression also exercises the unbounded-growth case I raised. My concern is resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ResultMessage is being sent while background agent is still working and turn is finished

3 participants